Ζωγραφική με χάρτες υφών στην OpenGL

Ο ορισμός διαφορετικών χρωμάτων σε κάθε κορυφή των τριγώνων και η παρεμβολή του χρώματος στα εσωτερικά σημεία λειτουργεί αποτελεσματικά μόνο για απλά σχήματα. Για να ζωγραφίσουμε μία ρεαλιστική σκηνή με πολλές λεπτομέρειες, θα χρειαζόταν να ορίσουμε πάρα πολλά τρίγωνα με πάρα πολλές κορυφές και να ξοδέψουμε πολύ χρόνο στην εύρεση του σωστού χρώματος για κάθε κορυφή. Θα ήταν καλύτερο και αποδοτικότερο αν μπορούσαμε να ζωγραφίσουμε μεγάλα τρίγωνα, που είναι γεωμετρικά απλά, και να “κολλήσουμε” επάνω τους, σαν ταπετσαρία, μία εικόνα που να δίνει την εντύπωση μιας εξαιρετικά λεπτομερούς επιφάνειας.

Αυτή ακριβώς η τεχνική χρησιμοποιείται κατά κόρον στον προγραμματισμό γραφικών, οι εικόνες, δε, που εφαρμόζονται επάνω στις απλές γεωμετρικά επιφάνειες ονομάζονται χάρτες υφών (texture maps) ή, απλώς, υφές (textures).

Ας αρχίσουμε ορίζοντας ένα τρίγωνο, επάνω στο οποίο θα εφαρμόσουμε αργότερα το texture μας. Μέσα στο πρόγραμμά μας και πριν την εκτέλεση του κυρίως βρόχου γράφουμε:

// === SHADERS ===
shaderProgram = loadShaders("texture.vertexshader", "texture.fragmentshader");

// === DATA ===
const GLfloat vertices[] = {
     0.0f,  0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f
};

// === VAOs ===
glGenVertexArrays(1, &triangleVAO);
glBindVertexArray(triangleVAO);

// === VBOs ===
glGenBuffers(1, &verticesVBO);
glBindBuffer(GL_ARRAY_BUFFER, verticesVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(0);

Στον κυρίως βρόχο του προγράμματος, προσθέτουμε τον απαραίτητο κώδικα για να ζωγραφιστεί το τρίγωνο:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

glUseProgram(shaderProgram);

glBindVertexArray(triangleVAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

glfwSwapBuffers(window);
glfwPollEvents();

Γράφουμε, επίσης, και απλούς vertex shader και fragment shader για την απευθείας ζωγραφική του τριγώνου.

#version 330 core

layout(location = 0) in vec3 vertexPosition;

void main()
{
    gl_Position = vec4(vertexPosition, 1.0);
}
#version 330 core

out vec4 color;

void main()
{
    color = vec4(0.0, 0.0, 0.0, 1.0);
}

Αν έχουν γίνει όλα σωστά, θα πρέπει, εκτελώντας τον κώδικα να βλέπουμε ένα μαύρο τρίγωνο:

A black triangle

Έστω ότι θέλαμε να επικολλήσουμε στο τρίγωνο που ζωγραφίσαμε την ακόλουθη εικόνα:

Colorful rocks

Αμέσως συναντάμε το πρώτο πρόβλημα: το σχήμα της εικόνας είναι ορθογώνιο και δεν ταιριάζει ακριβώς επάνω στο τρίγωνο. Γίνεται εμφανές ότι θα πρέπει να συσχετίσουμε κάπως τα σημεία του τριγώνου με τις συντεταγμένες της εικόνας. Επειδή ο συσχετισμός αυτός αν έπρεπε να γίνει για κάθε fragment του τριγώνου θα ήταν εξαιρετικά δύσκολος και έχοντας την εμπειρία χρήσης γνωρισμάτων (attributes) σε κορυφές, θα μπορούσαμε να συσχετίσουμε μόνο τις κορυφές του τριγώνου σε συντεταγμένες της εικόνας, να προωθήσουμε τον συσχετισμό αυτό στον vertex shader ως ένα επιπλέον γνώρισμα των κορυφών και να το στείλουμε στο fragment shader, όπου η τιμή θα υπολογιστεί μέσω παρεμβολής.

Ας ορίσουμε καταρχάς έναν τετριμμένο συσχετισμό του τριγώνου και των συντεταγμένων της εικόνας:

Mapping the triangle to the image with the rocks

Βλέπουμε πως αναφερόμαστε στις κανονικοποιημένες συντεταγμένες της εικόνας, δηλαδή στο εύρος [0, 1]. Αξίζει να σημειωθεί ότι συχνά, για να μη συγχέονται οι συντεταγμένες ενός texture με τις συντεταγμένες του κόσμου, είθισται να μην αναφερόμαστε στις πρώτες με τα γράμματα X και Y, αλλά με τα γράμματα U και V ή S και T.

Οι χάρτες υφής είναι ένας τρόπος να συσχετίσουμε σύνθετη πληροφορία χρώματος σε ένα απλό σχήμα. Όμως, αυτή δεν είναι η μόνη χρήση τους. Μπορούμε να χρησιμοποιήσουμε χάρτες υφής για να συσχετίσουμε οποιαδήποτε σύνθετη πληροφορία σε απλά σχήματα, ακόμη και πληροφορία που δεν είναι δισδιάστατη. Έτσι προκύπτει η ανάγκη για επέκταση των UV ή ST συντεταγμένων. Οι ST συντεταγμένες επεκτείνονται σε STPQ ενώ οι UV συντεταγμένες δεν έχουν κάποια δεδομένη επέκταση.

Πριν την εκτέλεση του κυρίως βρόχου, ορίζουμε τις UV συντεταγμένες, τις στέλνουμε στην GPU χρησιμοποιώντας ένα νέο VBO και δημιουργούμε ένα νέο γνώρισμα στο VAO ώστε να τις στείλουμε στο vertex shader:

const GLfloat uvs[] = {
    0.5f, 1.0f,
    1.0f, 0.0f,
    0.0f, 0.0f
};

glGenBuffers(1, &uvsVBO);
glBindBuffer(GL_ARRAY_BUFFER, uvsVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(uvs), uvs, GL_STATIC_DRAW);

glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(1);

Αξίζει να προσέξουμε το 2 στη συνάρτηση glVertexAttribPointer που προκύπτει από το γεγονός ότι σε κάθε κορυφή αντιστοιχούν 2 δεκαδικοί αριθμοί και όχι 3, όπως προηγουμένως.

Επιπλέον, είναι ανάγκη να φορτώσουμε στη μνήμη τα δεδομένα της εικόνας. Αυτό μπορεί να γίνει μέσω της βιβλιοθήκης SOIL (Simple OpenGL Image Library).

texture = loadSOIL("texture.bmp");

Η συνάρτηση loadSOIL φορτώνει στη μνήμη την εικόνα “texture.bmp”. Έπειτα, εκτελεί όλες τις απαραίτητες κλήσεις στην OpenGL για τη δημιουργία ενός texture με αυτή την εικόνα ως περιεχόμενο και επιστρέφει το μη-προσημασμένο ακέραιο αριθμό που αντιστοιχεί στο texture.

Τώρα, κάθε φορά που θέλουμε να ζωγραφίσουμε ένα VAO που χρησιμοποιεί αυτό το texture, θα πρέπει πριν τη χρήση της συνάρτησης glDrawArrays (ή άλλης αντίστοιχης συνάρτησης ζωγραφικής) να ενεργοποιούμε το texture ως εξής:

glBindTexture(GL_TEXTURE_2D, texture);

Όσον αφορά τον vertex shader, θα πρέπει να δεχόμαστε το επιπλέον γνώρισμα των κορυφών (UV συντεταγμένες) και να το προωθούμε στον fragment shader, στον οποίο θα φτάσει μέσω παρεμβολής:

#version 330 core

layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec2 vertexUV;

out vec2 uv;

void main()
{
    gl_Position = vec4(vertexPosition, 1.0);

    uv = vertexUV;
}

Στον fragment shader θα πρέπει να δεχτούμε σαν είσοδο τις UV συντεταγμένες που προώθησε ο vertex shader και να λάβουμε την τιμή του texture στις αντίστοιχες UV συντεταγμένες. Η διαδικασία αυτή λέγεται δειγματοληψία (sampling) και η OpenGL την πραγματοποιεί μέσω ενός αντικειμένου που ονομάζεται δειγματολήπτης (sampler).

#version 330 core

in vec2 uv;

out vec4 color;

uniform sampler2D textureSampler;

void main()
{
    color = vec4(texture(textureSampler, uv).rgb, 1.0);
}

Ορίσαμε μία uniform μεταβλητή textureSampler η οποία είναι τύπου sampler2D (επειδή επιθυμούμε να δειγματοληπτήσουμε ένα δισδιάστατο texture). Έπειτα, χρησιμοποιήσαμε τη συνάρτηση texture η οποία λαμβάνει ως ορίσματα έναν δειγματολήπτη και τις συντεταγμένες στις οποίες επιθυμούμε να δειγματοληπτήσουμε το texture και επιστρέφει το χρώμα στο σημείο εκείνο.

Εάν όλα πήγαν καλά, θα μπορούμε να δούμε το τρίγωνό μας με την εικόνα υφής εφαρμοσμένη επάνω του:

A triangle with rocks drawn on it

Ορισμός παραμέτρων υφών

Μόνο για αυτή την ενότητα, ας αλλάξουμε τον ορισμό του χρώματος στον fragment shader ως ακολούθως:

color = vec4(texture(textureSampler, vec2(uv.x + 0.3, uv.y)).rgb, 1.0);

Με τον τρόπο αυτό μετατοπίσαμε τις UV συντεταγμένες 0.3 μονάδες προς τα αριστερά, φέρνοντάς τες έξω από το “επιτρεπτό” εύρος [0, 1]. Ας δούμε τι θα ζωγραφιστεί στο δεξί τμήμα του τριγώνου (που λαμβάνει πια τιμές μεγαλύτερες από 1):

The texture repeats; a seam is visible on the right side of the triangle

Παρατηρούμε ότι εκτός του “επιτρεπτού” εύρους η OpenGL επαναλαμβάνει την ίδια εικόνα (ουσιαστικά, αγνοεί το ακέραιο μέρος των UV συντεταγμένων). Αυτή είναι η προεπιλεγμένη συμπεριφορά της OpenGL όμως αυτή, όπως και πολλές άλλες παραμέτρους της OpenGL που αφορούν τη διαχείριση των textures, μπορούμε να τη μεταβάλουμε.

Η μεταβολή των παραμέτρων των textures γίνεται χρησιμοποιώντας την οικογένεια συναρτήσεων glTexParameter*. Η κατάληξη της συνάρτησης εξαρτάται από τον τύπο της τιμής που θέλουμε να αποδώσουμε στην παράμετρο. Για παράδειγμα, θα μπορούσαμε να χρησιμοποιήσουμε τη συνάρτηση glTexParameteri για να αλλάξουμε τη συμπεριφορά ώστε έξω από το εύρος [0, 1] στον άξονα S (η OpenGL χρησιμοποιεί τα γράμματα S και T αντί των U και V) να έχει ένα σταθερό χρώμα:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);

Το i στο τέλος της συνάρτησης προέρχεται από το integer. Η σταθερά GL_CLAMP_TO_BORDER έχει οριστεί ως ο ακέραιος αριθμός 33069.

Το αποτέλεσμα είναι να βλέπουμε μαύρο χρώμα στο δεξί μέρος του τριγώνου:

The right part of the triangle is black, the rest is still rocks

Εάν επιθυμούσαμε να αλλάξουμε το χρώμα εκτός των ορίων, θα χρησιμοποιούσαμε τη συνάρτηση glTexParameterfv:

float borderColor[] = { 0.0f, 0.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

Το fv στο τέλος της συνάρτησης προέρχεται από το float vector. Η μεταβλητή borderColor αναπαριστά ένα διάνυσμα δεκαδικών αριθμών.

Το τρίγωνο στα σημεία στα οποία η συντεταγμένη U είναι μεγαλύτερη από 1 έχει τώρα μπλε χρώμα:

The right part of the triangle is blue, the rest is still rocks

Μέσω των συναρτήσεων glTexParameter* έχουμε τη δυνατότητα να ρυθμίσουμε πολλές ακόμη παραμέτρους των υφών που χρησιμοποιούμε.

Μονάδες υφής

Αναρωτηθήκατε πώς ο δειγματολήπτης γνωρίζει ποιο texture να δειγματοληπτήσει στις συντεταγμένες που δώσαμε στη συνάρτηση texture; Όταν το VAO μας χρησιμοποιεί μόνο ένα texture η απάντηση είναι προφανής: όποιο είναι ενεργό (όποιο, δηλαδή, έχουμε κάνει bind). Τι γίνεται όμως όταν πρέπει να χρησιμοποιήσουμε περισσότερα textures για ένα αντικείμενο; Και γιατί χρησιμοποιήσαμε uniform μεταβλητή; Πού ορίζεται η τιμή της;

Τα παραπάνω ερωτήματα απαντούν οι μονάδες υφής (texture units). Οι μονάδες υφής αντιπροσωπεύουν τις δυνατές τοποθεσίες μνήμης στις οποίες μπορεί να τοποθετηθεί ένα texture. Η ανάθεση ενός texture σε μία μονάδα υφής γίνεται με την κλήση της εντολής glBindTexture. Επειδή η προεπιλεγμένη μονάδα υφής είναι η μονάδα 0, το texture μας ενεργοποιήθηκε σε αυτή τη μονάδα υφής. Θα ήταν, δηλαδή, σωστότερο να γράφαμε στον κυρίως βρόχο του προγράμματος:

glBindVertexArray(triangleVAO);

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);

Πρέπει, επίσης, να λάβουμε από το shaderProgram τη θέση της μεταβλητής textureSampler που αντιπροσωπεύει τον δειγματολήπτη:

textureSampler = glGetUniformLocation(shaderProgram, "textureSampler");

και, πριν ζωγραφίσουμε το VAO, να του δώσουμε την τιμή της μονάδας υφής που θέλουμε να δειγματοληπτήσει:

glUniform1i(textureSampler, 0);

Έστω τώρα ότι επιθυμούμε να υπερθέσουμε ένα δεύτερο texture επάνω στο προηγούμενο:

Still image of flowing water

Στον fragment shader θα ορίσουμε μία δεύτερη μεταβλητή τύπου sampler2D, η οποία θα δειγματοληπτεί το άλλο texture.

#version 330 core

in vec2 uv;

out vec4 color;

uniform sampler2D textureSampler;
uniform sampler2D otherTextureSampler;

void main()
{
    vec4 main_texture = vec4(texture(textureSampler, uv).rgb, 1.0);
    vec4 other_texture = vec4(texture(otherTextureSampler, uv).rgb, 1.0);

    color = mix(main_texture, other_texture, 0.7);
}

Η συνάρτηση mix που χρησιμοποιήσαμε παραπάνω δημιουργεί μία γραμμική παρεμβολή των περιεχομένων του πρώτου και του δευτέρου ορίσματος με το ποσοστό που δίνουμε στο τρίτο όρισμα. Εδώ, το τελικό χρώμα του fragment θα αποτελείται κατά 30% από το χρώμα που λάβαμε από το main_texture και κατά 70% από το χρώμα που λάβαμε από το other_texture.

Σε αυτή την περίπτωση, χρησιμοποιήσαμε τις ίδιες UV συντεταγμένες για τις δύο εικόνες. Είναι, όμως, σαφές ότι θα μπορούσαμε να είχαμε χρησιμοποιήσει διαφορετικές UV συντεταγμένες για κάθε εικόνα ορίζοντάς τες ως δύο διαφορετικά γνωρίσματα των κορυφών του τριγώνου.

Στην αρχή του προγράμματός μας, θα πρέπει τώρα να φορτώνουμε και το άλλο texture και να αποθηκεύουμε και την τοποθεσία του άλλου δειγματολήπτη:

texture = loadSOIL("texture.bmp");
otherTexture = loadSOIL("other_texture.bmp");

textureSampler = glGetUniformLocation(shaderProgram, "textureSampler");
otherTextureSampler = glGetUniformLocation(shaderProgram, "otherTextureSampler");

Τέλος, κάθε φορά που θέλουμε να ζωγραφίσουμε το triangleVAO, θα πρέπει:

  1. να ενεργοποιούμε μία μονάδα υφής για κάθε texture,
  2. να κάνουμε bind το αντίστοιχο texture στη μονάδα υφής και
  3. να ενημερώνουμε τον κατάλληλο δειγματολήπτη για τη μονάδα υφής στην οποία αντιστοιχίσαμε το texture:
glBindVertexArray(triangleVAO);

// === texture ===
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glUniform1i(textureSampler, 0);

// === other_texture ===
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, otherTexture);
glUniform1i(otherTextureSampler, 1);

glDrawArrays(GL_TRIANGLES, 0, 3);

Το αποτέλεσμα της εκτέλεσης του κώδικα φαίνεται παρακάτω:

A triangle textured with colorful rocks and flowing water overlaid on top

Υλικό για εμβάθυνση

LearnOpenGL: Textures
open.gl: Textures
opengl-tutorial: A Textured Cube
ogldev: Basic Texture Mapping
Anton Gerdelan: “Anton’s OpenGL 4 Tutorials”, pp. 170–184, 248–251